Skip to content

fix(spanner): record streaming operation completion when stream finishes - #18704

Open
sinhasubham wants to merge 1 commit into
mainfrom
fix-operation-latency
Open

sinhasubham wants to merge 1 commit into
mainfrom
fix-operation-latency

Conversation

@sinhasubham

@sinhasubham sinhasubham commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes an issue where operation_latencies and operation_count client metrics were not being emitted for streaming queries (ExecuteStreamingSql).

Key Changes

  • Operation Completion Lifecycle: Wrapped stream consumption in _restart_on_unavailable in a try...finally block (sync and async in snapshot.py) to guarantee metrics_tracer.record_operation_completion() is called when streams finish normally, exit early via break, or raise unhandled exceptions.
  • Stream Termination Handling: Updated _StreamingResponseWrapper and _AsyncStreamingResponseWrapper in metrics_interceptor.py to trigger attempt metric recording when encountering last=True on partial result sets.
  • Status Integrity: Preserved unpopulated operation status convention rather than defaulting to "OK" upon caught gRPC stream exceptions.
  • Unit Test Coverage: Added comprehensive test coverage for early break, generator cancellation, and last=True chunk handling across both sync and async paths.

Validation

Verified using live test runs on project span-cloud-testing (instance=ranjithmrtest, database=gsql_enterprise_final) confirming that operation_latencies and attempt_latencies are actively emitted to Cloud Monitoring for ExecuteStreamingSql.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the Spanner client's metrics tracking to ensure that streaming operation latencies are recorded immediately upon stream completion and exclude consumer processing delays. This is achieved by tracking stream completion in both synchronous and asynchronous snapshot iterators and recording metrics accordingly. However, the reviewer identified a critical issue in metrics_interceptor.py where the code attempts to check for a last attribute on the yielded stream items. Since the production PartialResultSet protobuf messages do not contain a last field, this check will always evaluate to False and act as dead code. It is recommended to remove these redundant checks.

@sinhasubham

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the Spanner client to record operation completion metrics immediately when the stream finishes or when a PartialResultSet with last=True is observed, rather than waiting for background stream draining. The feedback suggests simplifying the boolean checks in metrics_interceptor.py by removing the explicit 'is True' comparisons to align with PEP 8 guidelines.

@sinhasubham
sinhasubham force-pushed the fix-operation-latency branch from 1b8a925 to 31b7a08 Compare October 27, 2026 06:44
…empt latency upon stream completion

Wait until the streaming response completes (either via PartialResultSet.last=True
or iterator exhaustion/closure) before recording operation completion and attempt
completion metrics in _restart_on_unavailable and _StreamingResponseWrapper /
_AsyncStreamingResponseWrapper.

Fixes #18656
@sinhasubham
sinhasubham force-pushed the fix-operation-latency branch from 31b7a08 to e37b728 Compare October 27, 2026 10:29
@sinhasubham
sinhasubham marked this pull request as ready for review October 27, 2026 10:47
@sinhasubham
sinhasubham requested a review from a team as a code owner October 27, 2026 10:47
@sinhasubham
sinhasubham requested a review from rahul2393 October 27, 2026 10:53
Comment on lines +1044 to +1046
items_done = [it async for it in resumable_done]
self.assertEqual(items_done, [item_two])
mock_tracer.record_operation_completion.assert_called_once()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This half passes on the old code too, so it doesn't pin the new "record before the final yield" behavior. After item_two, the list comprehension asks for one more item. That resumes the generator, and on the old code the finally records completion right then, so assert_called_once() passes either way.

In real use, StreamedResultSet stops pulling after the final chunk and never makes that extra call, which is exactly the case this PR fixes. Stopping after the item the same way makes the test fail on the old code (Called 0 times) and pass with your change. I ran both locally:

Suggested change
items_done = [it async for it in resumable_done]
self.assertEqual(items_done, [item_two])
mock_tracer.record_operation_completion.assert_called_once()
# Take the final item and stop pulling, like StreamedResultSet does.
first = await resumable_done.__anext__()
self.assertEqual(first, item_two)
# Completion is recorded before the final yield...
mock_tracer.record_operation_completion.assert_called_once()
await resumable_done.aclose()
# ...and the finally doesn't record it a second time.
mock_tracer.record_operation_completion.assert_called_once()

Optionally, a last=True variant (a single PartialResultSet(last=True), with _drain_stream patched) would cover the other way stream_finished gets set.

request_id_manager=session._database,
resource_info={"project": "p"},
)
self.assertEqual(list(resumable_done), [item_two])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the async test: list(...) makes one extra next() call, which runs the old code's finally, so this passes without the fix. The new mock-server tests already cover the sync path (3 of the 6 fail without the fix), so this one matters less, but it keeps the two test files consistent:

Suggested change
self.assertEqual(list(resumable_done), [item_two])
# Take the final item and stop pulling, like StreamedResultSet does.
first = next(resumable_done)
self.assertEqual(first, item_two)
# Completion is recorded before the final yield...
mock_tracer.record_operation_completion.assert_called_once()
resumable_done.close()
# ...and the finally doesn't record it a second time.
mock_tracer.record_operation_completion.assert_called_once()

Comment on lines +211 to +214
# Operational latency must include the ~90ms server stream duration (>= 75ms)
# and exclude the 180ms consumer sleep (<= 165ms).
self.assertGreaterEqual(point.sum, 75)
self.assertLessEqual(point.sum, 165)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the upper bounds here (<= 165, and <= 140 in test_operation_latency_last_true_and_last_false_consistency) leave only ~70 ms of headroom over the simulated server delay, which could flake on a slow CI runner.

Since _make_partial_result_sets doesn't set resume tokens, the whole stream is buffered before the first row is yielded. A deterministic alternative: take the first row (rows = iter(results); next(rows)) and assert that operation_latencies is already recorded before consuming the rest. The >= 75 lower bound can stay.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants